iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 8

Day 8|Observable 與 Promise 的世界觀差異

  • 分享至 

  • xImage
  •  

這週又是新大陸

上週是震驚期:DI 沒了、生命週期沒了、雙向綁定沒了,我一路在找對應方法,然後一路就是沒有。

這週換了個態度。找不到對應方法,那我就自己試著模擬看看。

RxJS 是我在 Angular 裡最倚賴的東西。switchMapdebounceTimecombineLatest——三年來所有非同步的問題,我都是用這套詞彙在思考的。我不相信換個框架,這套思考方式就沒用了。

所以這週的計畫是:能用 Angular 的方式解決的,就用 Angular 的方式先解決。

結果也是冒出一堆疑問了。


只是宣告了一下,請求就出去了

檔案頁要先載入清單。我照 Angular 的習慣,先把資料來源「準備好」:

檔案頁有一個「匯出」按鈕。我照 Angular 的習慣,先把資料來源準備好,等使用者按下去再用:

export function FileList() {
  const [keyword, setKeyword] = useState('');
  const filesPromise = api.getFiles();   // 先準備著,匯出時才用

  async function handleExport() {
    const files = await filesPromise;
    exportCsv(files);
  }

  return (
    <>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleExport}>匯出</button>
    </>
  );
}

打開 Network 面板:一進頁面,/api/files 就出去了。在搜尋框打一個字,又一次。

我根本還沒按匯出。
在 Angular,這樣寫是不會發請求的。


一、Angular:不訂閱,就什麼都不會發生

在 Angular,HttpClient 回傳的是 Observable:

const files$ = this.http.get<File[]>('/api/files');
// 到這裡為止,什麼都沒發生

這行只是描述了一個請求。它不會打 API、不會等回應、不會做任何事。要等到有人訂閱,管線建立了要有人來打開才行:

files$.subscribe(files => this.files = files);   // 現在才真的發出去

或者交給模板:

@for (file of files$ | async; track file.id) { ... }

這個特性叫 lazy(惰性):Observable 是一份藍圖,不是一個正在進行的動作。你可以把它傳來傳去、組合、轉換,只要沒人訂閱,就一個請求都不會出去。

所以最常見的新手 bug 是忘記 subscribe,結果請求根本沒發


二、Promise:一建立就出發

JavaScript 的 Promise 剛好相反。

const filesPromise = fetch('/api/files');
// 到這裡,請求已經出去了

Promise 是 eager的。建立的那一刻,裡面的工作就開始跑;.then() 只是讓你在它完成時拿到結果,跟「要不要執行」無關。

async 函式也一樣:

async function getFiles() {
  return (await fetch('/api/files')).json();
}

getFiles();   // 呼叫的瞬間就發出去了

回到我的 bug。api.getFiles() 寫在元件函式本體裡,而 Day 5 說過,元件函式每次 render 都會整個重跑一次

所以每一次 render,那行都會被執行,都會建立一個新的 Promise,都會發出一個新的請求。useEffect 只跑一次沒錯,但它等的那個 Promise 早就不是唯一一個了——其他幾個請求發出去,結果沒人接。

在 Angular,一個沒被訂閱的 Observable 不會造成任何後果。在 React,一個不小心寫在 render 裡的 Promise,每次 render 都在花你的 API 額度。

修法很直接,把它搬進 effect:

useEffect(() => {
  api.getFiles().then(setFiles);
}, []);

在 React 裡,「建立 Promise」本身就是副作用,它該待在副作用該待的地方。


三、四個差異

lazy 跟 eager 只是第一個。把兩邊攤開來看:

Observable Promise
什麼時候開始 被訂閱時(lazy) 建立時(eager)
能給幾個值 零到無限多個 恰好一個
能不能取消 unsubscribe() 不能
怎麼組合 上百個運算子 .then() 加上 Promise.all 那幾個

不過要誠實說一句:第二列在 HTTP 請求上其實沒差。 Angular 的 http.get() 也只會發出一個值然後結束,跟 Promise 一樣。多值的威力在事件、WebSocket、計時器這類東西上才看得出來。

真正讓我這週一直撞牆的,是另外三列:

不能取消。 Promise 一旦出發就回不來。使用者切了頁、打了新的關鍵字,舊的請求還是會跑完、還是會 resolve,你只能在它回來時選擇忽略。瀏覽器提供了 AbortController 可以中止 fetch,但那是 fetch 的能力,不是 Promise 的——這個明天會專門講。

沒有運算子。 debounceTimedistinctUntilChangedretryswitchMap——RxJS 裡一行就解決的事,在 Promise 的世界全部要自己寫。

不是一條管線。 這個最根本,下一節說。


四、我的第一個反應:把 RxJS 裝進來

RxJS 不是 Angular 的專屬品,它是一個獨立的套件。React 專案一樣能 npm install rxjs

所以我寫了第一個 hook:

function useObservable<T>(source$: Observable<T>, initial: T) {
  const [value, setValue] = useState(initial);

  useEffect(() => {
    const sub = source$.subscribe(setValue);
    return () => sub.unsubscribe();
  }, [source$]);

  return value;
}

基本上就是自己刻了一個 | async。能動,而且當下覺得很有成就感。

然後問題一個一個浮上來。

source$ 每次 render 都是新的。 在元件裡寫 const files$ = api.getFiles$(),Day 4 的問題原封不動重演——依賴陣列看到新參考,每次都重新訂閱。要嘛 useMemo 包起來,要嘛搬到元件外面。每一個 Observable 都得這樣處理。

StrictMode 下會訂閱兩次。 開發模式故意跑兩次 effect,cold Observable 就真的發兩次請求。清除函式寫對了不會壞,但 Network 面板會一直讓你懷疑自己。

整個生態系都在講 Promise。 React Router 的 loader 要的是 Promise。資料請求套件要的是 Promise。表單套件的非同步驗證要的是 Promise。我在一個 Promise 的世界裡,硬是維護一條 Observable 的平行線,每到邊界就要轉換一次。

到這裡我意識到,問題不只是 API 不相容。


五、Observable 描述的是時間,React 不需要

在 Angular,我把資料想成一條會流動的管線

this.files$ = this.keyword$.pipe(
  debounceTime(300),
  switchMap(k => this.api.search(k)),
);

keyword$ 是「關鍵字隨時間變化的序列」,files$ 是「搜尋結果隨時間變化的序列」。整段程式碼描述的是時間軸上的關係:每當上游有新值,下游就跟著變。

React 不是這樣思考的。

在 React,keyword 就是一個值——此時此刻的值。沒有序列,沒有管線,只有一個字串。

const [keyword, setKeyword] = useState('');

那「隨時間變化」這件事去哪了?答案是 re-render

每次 keyword 變了,元件函式整個重跑一次,拿到新的值,算出新的畫面。時間不是被描述在資料流裡,而是被切成一格一格的 render——每一次 render 都是一張快照,快照裡的每個值都是靜止的。

所以 Observable 在 React 裡總覺得格格不入。它想描述的「隨時間變化」,React 已經用自己的方式處理掉了:你不需要一條管線把值送到畫面,因為元件每次都會重新拿一次最新的值。

Observable 需要時間軸,React 只給你快照。兩種世界觀在搶同一件事的主導權。

這不代表 RxJS 在 React 裡沒用。WebSocket、複雜的事件組合、多個來源的合併——這些本質上就是串流的東西,Observable 依然是最好的描述方式(Week 4 會遇到)。但拿它來處理「一個請求、拿一個結果」這種日常,就像用一整套水管系統去裝一杯水。


今天的結論

Observable 跟 Promise 看起來都是「非同步的值」,但它們回答的是不同的問題。

Observable 是一份藍圖:lazy、可以取消、可以有很多個值、有一整套運算子,描述的是一條隨時間流動的管線

Promise 是一張收據:eager、不能取消、只有一個值,描述的是一件已經開始、未來會有結果的事

Angular 選了前者,所以整個框架都建立在「資料是串流」的假設上。React 選了後者,因為它根本不需要串流——時間被 re-render 切成快照,每張快照裡只需要當下的值。

那個一直發出去的請求,就是兩種假設撞在一起的地方:我以為我在畫藍圖,其實每一次 render 都在簽一張新收據。

RxJS 我先收起來了,但它教我的那些問題不會消失——使用者打字太快怎麼辦、舊的請求比新的晚回來怎麼辦、兩個請求要一起等怎麼辦。

明天討論:switchMap 不見了,我要自己處理競態。


上一篇
Day 7|沒有 @angular/cli之後:結構要自己立規矩
下一篇
Day 9|switchMap 不見後,才知道它一直在幫我擋什麼
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言